#!/usr/bin/perl
#
# Copyright 2008-2010 Vadim Zhukov <persgray@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
#    list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY AGAVA COMPANY ``AS IS'' AND ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
# EVENT SHALL THE AGAVA COMPANY OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# The views and conclusions contained in the software and documentation are
# those of the authors and should not be interpreted as representing official
# policies, either expressed or implied, of the AGAVA Company.

use bytes;
use strict;

use File::Basename;
use Getopt::Std;
use RB::Dir;
use RB::Log;
use RB::Profile;
use RB::RotateScheme::Daily;
use RB::RotateScheme::Weekly;
use RB::RotateScheme::Monthly;


# For Getopt::Std
our $VERSION = "3.0";
$Getopt::Std::STANDARD_HELP_VERSION = 1;

# For RB::Log
our $LOGPROGNAME = 'rb_rotate';


#######################################
# Read configuration

sub HELP_MESSAGE {
	print <<EOU;
Usage:
rb_rotate [-nv] [-D days] [-M months] [-W weeks] [-d dir] [-m module] profile
          host [...]
    -D Override profile number of maximum daily archives kept
    -M Override profile number of maximum monthly archives kept
    -W Override profile number of maximum weekly archives kept
    -d Specify directory where archives are kept (defaults to ~/dumps/rb)
    -m Specify module list (comma-separated) to rotate only
    -n Test mode: do not actually remove any files
    -v Verbose mode: print process of taking decision
Profile can be set as relative (to ~/.rb/) or absolute path.
It's not an error to specify unexisting module name in -m.
EOU
}

my %CmdOptions;
$Getopt::Std::STANDARD_HELP_VERSION = 1;
getopts('D:M:W:d:m:nv', \%CmdOptions);

if (scalar(@ARGV) < 2) {
	HELP_MESSAGE;
	exit (scalar(@ARGV) != 0);
}

my $profilePath = shift @ARGV;
errx (1, "No profile given") unless $profilePath ne '';
if (substr($profilePath, 0, 1) ne '/') {
	$profilePath = $ENV{'HOME'}.'/.rb/'.$profilePath;
}

our $Profile = RB::Profile->new($profilePath);
our $Config = $Profile->{'global'};

# Set test mode flag
if ($CmdOptions{'n'}) {
	$Config->{'test_mode'} = 1;
}

# Set verbosity level
if ($CmdOptions{'v'}) {
	$Config->{'verbosity'} = 2;
}

# Override archive directory?
if ($CmdOptions{'d'}) {
	# No need to glob(), it's already coming from command line
	$Config->{'archive_dir'} = $CmdOptions{'d'};
} else {
	($Config->{'archive_dir'}) = (glob $Config->{'archive_dir'});
}
if ($Config->{'archive_dir'} eq '') {
	$Config->{'archive_dir'} = '.';
}

my %OnlyModules;
if ($CmdOptions{'m'}) {
	%OnlyModules = map { ($_, 1) } (split ('\s*,\s*', $CmdOptions{'m'}));
}

# Cache: moduleName -> [ rotate schemes ]
my %RotateSchemes;
foreach my $moduleName (@{$Config->{'backup_modules'}}) {
	my $module = $Profile->{$moduleName};
	$RotateSchemes{$moduleName} = [];
	push(@{$RotateSchemes{$moduleName}}, new RB::RotateScheme::Monthly
	    ($CmdOptions{'M'} || $module->{'rotate_months'}))
	    if $module->{'rotate_months'};
	push(@{$RotateSchemes{$moduleName}}, new RB::RotateScheme::Weekly
	    ($CmdOptions{'W'} || $module->{'rotate_weeks'}))
	    if $module->{'rotate_weeks'};
	push(@{$RotateSchemes{$moduleName}}, new RB::RotateScheme::Daily
	    ($CmdOptions{'D'} || $module->{'rotate_days'}))
	    if $module->{'rotate_days'};
}


# Rotates backups in a given directory. At the start we include all archives in
# rotation, and then exclude them by given rules.
#
# Arguments:
#   $dir             - directory where to rotate backups
#   $all             - all backups that can be cleaned, including damaged
#   $ok              - fully done backups, only (part or all of) them will be
#                      saved
#   $schemes         - arrayref of rotate schemes
#   $itemBackupLines - 2-level hashref: item name => date => arrayref of
#                      RB::Archive representing archives required to reconstruct
#                      full backup on "date".
sub rotateDir($$$$) {
	my ($all, $ok, $schemes, $itemBackupLines) = splice(@_, 0, 4);

	logIt (1, "        All found dates: ".join(',', sort keys %{$all})."")
	    if %{$all};

	# List of dates to be removed, we'll exclude items from it later
	my %remove = ( %{$all} );

	# Exclude items via rotate schemes
	foreach my $scheme (@{$schemes}) {
		foreach my $date ($scheme->makeKeepDecision(keys %{$ok})) {
			# Exclude archives required by incremental ones too
			foreach my $ar (@{$itemBackupLines->{$date}}) {
				if (defined $ar) {
					# Backup line constructed successfully
					delete $remove{$ar->date}
					    if exists $remove{$ar->date};
				} else {
					# No need to check further, we should
					# remove unusable archives
					last;
				}
			}
		}
	}

	logIt (1, "        Dates to be removed: ".join(',', sort keys %remove))
	    if %remove;

	return 0 unless %remove;

	my $removedCount = 0;
	foreach my $date (keys %remove) {
		# Remove informaion file and archive itself.
		# File extension is taken from archive informaion, which we
		# can believe.
		foreach my $path ($remove{$date}->archivePath(),
		    $remove{$date}->infoPath()) {
			# NOTE: printing to STDOUT, so we can handle output in
			# easier, if we want. So no logIt() calls.
			print STDOUT $path."\n" if $Config->{'verbosity'} >= 1;
			unless ($Config->{'test_mode'} or unlink ($path)) {
				logIt (0, "Error removing ${path}: $!");
			}
			$removedCount++;
		}
	}
	return $removedCount;
}


#######################################

foreach my $host (@ARGV) {
	$host = lc($host);
	logIt (1, "Rotating backups for host $host");

	foreach my $moduleName (@{$Config->{'backup_modules'}}) {
		next if (%OnlyModules and !exists $OnlyModules{$moduleName});

		logIt (1, "Processing module $moduleName");
		my $module = $Profile->{$moduleName};
		my $moduleDir = $Config->{'archive_dir'}.'/'.$host.'/'.
		    $moduleName;

		# Get information to make rotate decisions on.
		my ($all, $ok) = scanBackupDir($moduleDir, $module);

		# 1. Build list of dates when any successful backup was made.
		#
		# 2. Build backup lines. We should not delete any backup
		# required  to reconstruct full backup from some older full
		# backup and incremental one(-s).
		my (%allOkDates, %backupLines);
		foreach my $itemName (keys %{$ok}) {
			foreach my $date (keys %{$ok->{$itemName}}) {
				$allOkDates{$date} = 1;
			}
			$backupLines{$itemName} = constructBackupLines(
			    values %{$ok->{$itemName}});
		}
		logIt(1, "    All dates in rotating: ".
		    join(", ", keys %allOkDates));

		# Fake OK date, so rotate scheme will include it
		# in rotating, but no actual remove action will be
		# perfomed, because they are done on $all members.
		# Surely, it's hack, but it makes our life too easy :)
		foreach my $okDate (keys %allOkDates) {
			foreach my $itemName (keys %{$all}) {
				unless (exists $all->{$itemName}->{$okDate}) {
					$ok->{$itemName}->{$okDate} = 1;
				}
			}
		}

		foreach my $itemName (sort keys %{$all}) {
			logIt(1, "    Rotating item $itemName");
			rotateDir($all->{$itemName},
			    ($ok->{$itemName} || {}),
			    $RotateSchemes{$moduleName},
			    $backupLines{$itemName}
			    );
		}
	}
}

exit 0;
